上一篇我們介紹了 Django Ninja 的內建分頁器,並用它實作了簡單的分頁功能。
雖然內建的PageNumberPagination
確實方便,但在很多時候,我們仍需要一些客製化功能。
為了實現這個目的,你需要自定義一個分頁類別。
不過別擔心,這種自定義,並非從零開始。而是繼承 Django Ninja 所提供的基礎分頁類別,再進行自己的「加工」。
這篇文章就要來教你怎麼做。
本文所有的程式碼變動,可參考這個 PR。
除了基本的分頁,我們還希望能夠:
page
)。per_page
)。這無疑是很常見的需求。我們將透過自定義分頁類別,來實現這些功能。
話不多說,直接開始!
分頁器(分頁類別)通常是是供全專案使用,所以不適合放在 Django app 目錄中。但也不能像 exception handlers 一樣,放在專案的api.py
,因為會引發循環引用。
所以,我在專案目錄 NinjaForum 建立一個新的 Python 模組——pagination.py
。
在這個新模組中,直接撰寫一個名為CustomPagination
的分頁類別,如下:
from typing import Any
from django.db.models.query import QuerySet
from ninja import Field, Schema
from ninja.pagination import PaginationBase
class CustomPagination(PaginationBase):
class Input(Schema):
page: int = Field(1, ge=1)
per_page: int = Field(10, ge=1, le=100)
class Output(Schema):
items: list
page: int = Field(examples=[1])
per_page: int = Field(examples=[10])
total: int = Field(examples=[100])
def paginate_queryset(
self,
queryset: QuerySet,
pagination: Input,
**params: Any,
) -> dict[str, Any]:
start = (pagination.page - 1) * pagination.per_page
end = start + pagination.per_page
return {
'items': queryset[start:end],
'page': pagination.page,
'per_page': pagination.per_page,
'total': queryset.count(),
}
這個分頁類別允許我們透過查詢參數——page
和per_page
——來決定分頁的大小與頁數,而且回應中還多了兩個同名的新欄位,作為額外的分頁資訊。
雖然程式碼看起來細節繁多,但仔細閱讀後,你會發現它其實不難理解。
限於篇幅,我們只挑一些重點來講。
PaginationBase
第一個疑惑應該是:「啊我怎麼會知道分頁類別要這樣寫?」
從官方文件我們可以得知,要繼承一個叫PaginationBase
的類別。但文件中對該類別的描寫還是有點簡略,所以需要看原始碼來了解更多的具體資訊。
然後模仿並覆寫類別中的一些屬性、方法——差不多就是如此。
class Input(Schema):
page: int = Field(1, ge=1)
per_page: int = Field(10, ge=1, le=100)
你一定能看出來,這個 Schema 就是拿來定義和驗證與分頁有關的 URL 查詢參數。
此外,Input
類別會作為引數傳入paginate_queryset
方法中,作為實現分頁邏輯的一部分。
Input
中的每一個屬性,就代表一個查詢參數(限分頁相關)——而且一樣可以使用Field
來設定細節!
這裡的Field
是 Pydantic 的Field
,我們在第 18 篇詳細介紹過。它允許我們為每個參數設定預設值、文件範例和基本的驗證規則。
本例中,page
的預設值是 1,且必須大於等於 1;per_page
的預設值是 10,且必須在 1 到 100 之間。這樣可以確保我們的分頁參數始終在合理的範圍內。
同樣的道理也適用於Output
,它決定了 HTTP 回應「應該要有」的格式,相當於分頁回應的 Schema。
這個方法是所有分頁類別的核心,它實現了具體的分頁邏輯。
它的第一參數是self
,可見它是一個「實例方法」。
最值得注意的是第二參數——queryset
,它實際上就是 view 函式的 return 值,而且類型必須是 QuerySet。
paginate_queryset
會利用我們熟悉的「切片與索引」,對傳入的 QuerySet 進行「切割」。這是 Django 為 QuerySet 自行實作的功能,行為上類似 Python 的list
、tuple
等容器。
當它回應給客戶端時,我們就得到了切片後的 QuerySet 和自定義的回應格式。
寫完上述的自定義類別,view 函式只要多一行@paginate(CustomPagination)
即可,這裡就省略程式碼。
直接看結果吧!我使用了/?page=2&per_page=5
(第 2 頁、每頁 5 筆)查詢參數:
十分理想!
那如果每頁數量設定為超過 100 會怎樣呢?
{
"detail": [
{
"type": "less_than_equal",
"loc": [
"query",
"per_page"
],
"msg": "Input should be less than or equal to 100",
"ctx": {
"le": 100
}
}
]
}
答案是 422 回應。
透過這兩篇文章,我們展示了如何在 DjangoNinja 中實作分頁,從簡單的內建方法,到複雜的自定義分頁類別。
根據專案需求,你可以選擇適合自己的分頁策略,讓每一個回應,都能以最適合的方式呈現給使用者。
還記得我們在第 13 篇、第 21 篇留下的伏筆嗎?
在〈卷 13:回應(一)Django Ninja 處理 HTTP 回應〉中我提到:
但我覺得這個「多重狀態碼回應」設定在實務上沒有很實用,為何?我們後續再談。
幫你複習一下,「多重狀態碼回應」指的是這個用法:
@api.post(
...,
response={200: Token, 401: Message, 402: Message} # 這裡
)
然後在 view 函式中,依照不同情況,給出不同的 return。
我在〈卷 21:錯誤處理(上)HttpError 與自定義 HTTP 回應〉又說了:
這樣看起來確實不錯,也很符合直覺,我以前寫 Django REST framework,都是這樣寫的。
可是,這個寫法在 Django Ninja 中,使用「分頁裝飾器」時,就會踢到鐵板了。
目前時機未到,在後續的〈卷 25:分頁(下)自定義分頁類別〉中,我們再把這件說清楚。
這不就來了嗎!
理由很簡單,關鍵就在於本文「重點三:paginate_queryset 方法」中的那句:
最值得注意的是第二參數——
queryset
,它實際上就是 view 函式的 return 值,而且類型必須是 QuerySet。
因為paginate_queryset
方法中,第二參數的類型必須是 QuerySet!
在paginate_queryset
內部,我們將這個參數視為 QuerySet 使用、操作。若傳入的不是 QuerySet,分頁邏輯就會出錯。
然而,多重狀態碼的回應,return 型別未必是 QuerySet——很可能是tuple
。
我舉一個簡單的例子你就懂,我們把「取得文章列表」API 改成這樣:
@api.get(
path="/posts",
response={200: list[PostResponse], 404: ErrorMessage}
)
@paginate(CustomPagination)
def get_posts(...) -> QuerySet[Post] | tuple[int, dict]:
posts = Post.objects.all()
if not posts.exists():
return 404, {"message": "沒有找到符合條件的文章"}
return posts
這個例子清楚地顯示了「多重狀態碼回應」與分頁器之間的衝突:
posts
),丟給分頁器進行分頁,一切運作良好。tuple
(因為 Django Ninja 非 200 回應必須有狀態碼,所以是tuple
),而不是 QuerySet。這將導致paginate_queryset
方法出錯,因為它預期接收一個 QuerySet,後續的內部操作也是以此為前提。
如果專案中所有的 API 都沒有分頁,使用「多重狀態碼回應」來處理「非 200」回應是完全可行的。
但只要有一個 API 需要分頁,這個有分頁的 API,為了避免上述衝突,就要改用一樣是第 21 篇提到的方式——raise HttpError
。
考慮到專案整體的一致性,其餘的 API,也應該採用raise HttpError
這個方式。
而分頁需求是如此的常見,所以「多重狀態碼回應」也就成為了雞肋。
本文同步發表於我的部落格——Code and Me